이벤트 기반 웹뷰 프레임워크 설계와 플러그인 생태계 만들기

📅 2023. 10. 22

FECONF 2023 이벤트 기반 웹뷰 프레임워크 설계와 플러그인 생태계 만들기 영상의 내용을 정리한 글입니다.

발표자: 원지혁 (당근마켓)

Chapter 1 - 화면 전환을 통해 유저 경험을 개선하기

  • 웹에서의 화면 전환은 딱딱 끊어지며, 적절한 맥락을 만들기 어렵고 유저에게 위화감을 줄 수 있음
  • 이동하거나 뒤로갈 때 이동하는 화면이 슬쩍(슬라이드 되게)보이는 애니메이션을 만들면 적절한 맥락을 전달하고 자연스러운 화면을 만들 수 있을거라고 생각
  • History API를 확장하려고 시도
    • 자연스러운 애니메이션을 구현하기 위해 현재 시점이 아닌 이전 화면에 대한 상태값이 필요
    • 그러나, History API는 이전 화면에 대한 상태값을 가져올 수 없었음
  • 처음에는 무식하게 History API를 감싸서 React 상태와 동기화하는 방법을 사용
  • karrotframe 출시
  • History API에 의존하면서 문제가 발생함
    • 의존성이 깊게 침투되어 있어 테스트하기 어려움
    • 상태 동기화에 버그가 발생함
      • 예를 들면, 새로고침 시 상태의 안정성이 떨어짐
    • 특정 기술 스택에 종속됨 - React 강제
    • 확장하기 어려움
      • react-router-dom에 의존하다보니, 동작에 대한 정의가 모호함
      • 예를 들어, 페이지가 추가되었을 때 무언가를 실행하고 싶음
      • react-router-dom 호출 후 콜백 함수를 호출해야 하는지, 실제 렌더링 될 때 useEffect에서 호출해야 하는지 명확한 룰이 존재하지 않음

Chapter 2 - 리팩토링

  • "처음부터 다시 만들기" - 무엇을, 어떻게 표현해야 할까?
  • 본질은 Transition, Navigation, URL이 아니라 Stack
    • 본질을 찾고나니 react-router-dom이나 History API에 대한 의존성을 제거할 수 있었음
  • "이벤트 기반으로 생각해보기" a.k.a Action, Message, Transition (useReducer, redux)
  • 이벤트 기반 설계는 유저가 행동 데이터를 발행하면, 데이터가 로직에 들어가서 현재 상태를 계산하는 방식
  • 만약에 유저가 추가 행동을 수행하면, 행동 여러개가 로직을 통하게 되고, 상태가 다시 계산됨
  • 상태는 로직에 행동 여러 개를 넣은 결과값
    • 상태: state
    • 로직: aggregate (행동 여러개를 모은다는 의미)
    • 행동: event
  • 이를 코드로 바꾸면 const state = aggregate(events, currentTime)로 표현 가능
    • currentTime은 애니메이션 상태가 현재 시간에 받게 되어서 추가한 인자
function aggregate(events, currentTime) {
	let state = {};

	for (const event of events) {
	  /* state를 조작합니다 */
	}

	return state;
}
  • Stack을 구현하기 위해 어떤 이벤트가 필요할까?
    • Init 이벤트: 스택이 초기화 될 때 필요한 이벤트
    • Pushed 이벤트: 화면이 새 화면으로 덮어지는 이벤트
    • Popped 이벤트: 화면이 이전 화면으로 빠지는 이벤트
  • 테스트는 어떻게 할 수 있을까?
test("푸시하면 스택에 추가되고, 팝하면 맨 위 화면이 비활성화됩니다", () => {
    const events = [
        makeEvent("Init"),
        makeEvent("Pushed"),
        makeEvent("Pushed"),
        makeEvent("Popped"),
    ];

    const state = aggregate(events, nowTime());

    expect(state).toStrictEqual({
        /* ...기대하는 상태 값 */
    });
});
  • 외부 의존성 없이 핵심 로직 테스트 가능
  • "리액트와 어떻게 통합하나요?"
  • CoreStore라는 객체로 로직을 래핑
  • dispatch() 라는 이름으로 Event를 추가하는 함수를 작성
  • 내부에서 aggregate() 함수가 작동해 상태를 계산
  • 현재 상태를 반환하는 getState() 함수와 상태가 변경될 때마다 콜백을 호출해주는 subscribe() 인터페이스를 노출
  • React 18의 useSyncExternalStore() API를 통해 React 내부 상태와 동기화
  • 이벤트 기반 웹뷰로 전환 이후, 버그를 수정하는 워크 플로우가 굉장히 심플해짐
    • 유저는 전체 이벤트 목록과 현재 상태를 JSON으로 전달할 수 있게 됨
    • 전달받은 JSON을 기반으로 이벤트를 분석해서 기대한 JSON 결과값과 맞는 지 확인하고 테스트 케이스에 추가

Chapter 3 - 플러그인 인터페이스로 쉬운 확장성 제공하기

  • 유저가 스스로 확장할 수 있는 인터페이스 만들기
  • 확장성: 어떤 일이 일어났을 때 유저가 스스로 원하는 작업을 수행할 수 있게 만드는 것
    • ... 했을 때(Callback), ... 을 수행한다(Action)
  • 상상력을 펼칠 수 있도록 미리 재료를 준비하기
    • 하지만, 지금 필요하지 않을 콜백을 미리 노출하면 일관되지 않은 인터페이스가 되어 쉽게 레거시가 될 수 있음
  • 이벤트 기반 설계에서는 굉장히 아름답고 일관적으로 해결 가능
  • 이전의 다이어그램에서 이펙트를 계산하는 produceEffect() 추가
  • 이벤트가 추가되기 전에 콜백 호출 (Pre-effect hook)
  • 상태가 변한 이후 이펙트가 발생할 때 콜백 호출 (Post-effect hook)
  • 기존에 선언해 놓은 이벤트와 1:1 구조를 가지게 되어 일관된 구성을 갖게 됨
  • 콜백 함수를 등록하는 인터페이스를 통해 개발자가 어떤 라이프 사이클을 가지고 동작하는지에 대해서 자연스럽게 학습을 유도할 수 있음
makeCoreStore({
	onInit() {},
	onBeforePush() {},
	onBeforePop() {},
	onPushed() {},
	onPopped() {},
})
  • 다만, 옵션이 너무 많아져서 개발자가 이해해야 하는 것이 많아짐
  • 만약 이벤트가 더 늘어난다면, 복잡도와 학습 비용은 늘어날 것
  • 이 때, "확장성은 유지하면서 유저가 더 쉽게 느끼도록 만들 수는 없을까?"
  • 아이디어: GraphQL Envelop
  • GraphQL 스키마는 다양한 라이프사이클이 존재하지만, 사용자들이 고쳐 쓰기엔 어려움
  • 해당 문제를 GraphQL Envelop은 다음과 같은 방식으로 해결
    • 유저가 구체적인 옵션을 모르더라도 플러그인을 통해 쉽게 확장 기능을 묶어서 쓸 수 있도록 제공
const getEnveloped = envelop({
	plugins: [
		useSchema(schema),
		useParserCache(),
		useValidationCache(),
	],
});
  • "우리 인터페이스도 이렇게 바꿔보면 어떨까?"
    • 기존 Option으로 넘기던 것을 Array<Option>으로 변경하기
  • As-is
makeCoreStore({
	onInit() {},
	onBeforePush() {},
	onBeforePop() {},
	onPushed() {},
	onPopped() {},
})
  • To-be
makeCoreStore({
  plugins: [
	{
	  onInit() {},
	  onBeforePush() {},
	  onBeforePop() {},
	  onPushed() {},
	  onPopped() {},
	}
  ],
})
  • 옵션 여러개가 묶인 객체들이 다음과 같이 묶이게 됨
makeCoreStore({
  plugins: [
	devToolsPlugin(),
	basicRendererPlugin(),
	basicUIPlugin(),
	historySyncPlugin(),
	mapInitialActivityPlugin(),
  ],
})
  • 통합 사례 1. historySyncPlugin()
    • 기존 karrotframe은 메인 모듈에 복잡한 로직들이 가득했음
    • Stackflow는 라이프사이클 훅과 History API의 onPopState 훅을 양방향으로 엮어서 구현
  • 통합 사례 2. devToolsPlugin()
    • Stackflow의 라이프사이클 훅을 통해 Chrome Extension과 통신하여 메인 모듈 업데이트 없이 이벤트와 상태를 실시간으로 확인 가능
  • 리팩토링 결과
    • 의존성이 깊게 침투되어 있어 테스트하기 어려움
      • ✅ 코어 로직에 의존성을 제거해 쉽게 테스트
    • 상태 동기화에 버그가 발생함
      • ✅ 이벤트 스냅샷을 통해 쉽게 디버깅
    • 특정 기술 스택에 종속됨
      • ✅ 다른 생태계에도 적용 가능
    • 확장하기 어려움
      • ✅ 라이프사이클 훅과 플러그인 인터페이스를 통해 쉽게 확장

Chapter 4 - 생태계 만들기

  • 기존 - 새로운 기능이 필요할 때 SDK 개발자에게 요청
  • 현재 - 플러그인 인터페이스를 통해 직접 개발
  • 기여에 대한 진입 장벽이 낮아지면서 핵심 유저가 증가함
  • 핵심 유저는 플러그인 또는 지식적인 노하우를 공유함
  • 이런 모델을 오르빗 모델이라고 부름
  • "생태계는 왜 중요할까?" 향상성 때문
    • 향상성이란, 내 코드의 수정 없이 앱이 향상되는 경험을 만들 수 있다는 것
    • ex: OS가 업데이트 되면서 내 앱의 성능이 자동으로 향상됨
    • 향상성은 내 코드가 버려지지 않을 거라는 의심없이 더 많은 기여를 할 수 있게됨
  • 신규 플러그인 생성 -> 생태계 성장 -> 플랫폼 전반적인 앱의 향상
  • 리소스가 부족한 라이브러리 팀에서는 플러그인 생태계를 향상성 확보의 유효한 전략으로 활용 가능

얻은 교훈 정리

  1. 이벤트 기반으로 설계하기
  2. 의존성 없는 코어 지식 만들기
  3. 라이프 사이클 노출하기
  4. 플러그인 인터페이스 만들기
  5. 핵심 유저가 기여할 수 있는 환경 만들기
  6. 향상성 증명하기

Q&A

  • Q. Stackflow는 Next.js와 같은 SSR 사용을 지양하는 내용을 올려놓으셨는데요. 하지만 SSR 관련 기술이 점점 올라오고 있는 추세인데 관련해서 의견을 묻고 싶습니다.
  • A. SSR을 도외시하는 건 아니지만, Stackflow 리팩토링 과정에서 SSR 지원에 대한 내용도 분명히 존재했습니다. 그래서 지금 Stackflow는 SSR 지원을 합니다. renderToString 이나 renderToPipeableStream 모두 사용이 가능합니다. 저희가 해야 할 일은 다음 페이지로 전환될 때 다음 페이지에 대한 데이터 의존성에 대해 prefetch와 같은 동작을 구현하는 일입니다. 저희가 원하는 Next.js의 기능은 어떤 화면이든 다음 화면에 대해 getServerSideProps를 호출하면 결과값을 바로 받아올 수 있는 API였는데, Next.js 소스코드를 찾아보니 해당 API가 없더라구요. 그래서 Next.js 코드를 어떻게든 짜집기해서 통합을 해놓은 예제를 만들었어요. 그런데 공식 기능이 아니라서 버전이 올라가면 작동이 안 될 수도 있으니, 추천하지 않는다고 README에 적었던거거든요. Next.js가 아닌 Vite SSR이나 직접 SSR을 구현하시면 통합할 수 있을거고, 커뮤니티에서도 그렇게 사용하고 있습니다.
  • Q. karrotframe에서 History API와의 동기화 문제로 새로 리팩토링을 하셨다고 했는데, Stackflow에서도 History API와 연계하는 플러그인이 있는 것으로 아는데, 해당 플러그인에서는 그런 문제를 해결하셨는지 궁금합니다.
  • A. 사실 이슈가 존재하는 건 마찬가지구요. 결국에는 복잡성을 어떻게 제어하느냐가 관건이거든요. 새 코드에서는 히스토리 관련 로직만 격리가 되있기 때문에, 더 안전하고 유지보수하기 쉬워졌다고 보시면 될 것 같습니다. Silver Bullet처럼 해결이 됐다! 라고 보기엔 어려운 것 같습니다.
  • Q. 코어 모듈을 React 내부로 돌려놓고 의존성을 제거하셨다고 하셨어요. 그리고, React의 useSyncExternalStore() API를 사용하셨다고 했는데, Solid나 Vue같은 다른 프레임워크에서도 사용이 가능한건지 궁금합니다.
  • A. 근본적인 구조 자체는 가능한 상태이지만, React 외 프레임워크 지원에 대한 리소스가 필요한 상황입니다. 당근마켓 내부에서는 React 외 다른 프레임워크를 사용하고 있지 않기 때문에, 현재는 해당 지원에 대한 우선순위가 높지 않은 상황입니다.
  • Q. 오랜 기간동안 라이브러리를 관리하신 것 같은데, 앞으로의 로드맵에 대해서 공유해주시며 좋을 것 같습니다.
  • A. 지금 당장은 Stackflow에 대한 로드맵은 없다고 해야 할 것 같아요. 지금은 주로 향상성에 대한 우선순위가 높은 상황입니다. 그래서 현재는 깃허브에 올라오는 feature request나 버그 제보에 대한 이슈를 주로 해결하고 있습니다. react-navigation이나 ionic같은 단계로 갈 수도 있겠지만, 지금은 생각을 좀 더 확장하고 넓은 생태계의 향상성을 가져올 수 있는 방법을 찾다가 배포 플랫폼에 우선순위를 두고 작업하게 되었다고 이해해주시면 될 것 같아요.